<?php
class FrontendImageField extends FileField {
    /**
     * @var array
     */
    private static $allowed_actions = array(
        'doUpload',
        'handleItem'
    );

    /**
     * @var array
     */
    private static $url_handlers = array(
        'upload/$ClassName/$ID' => 'doUpload',
        'item/$ClassName/$RecordID/$ID' => 'handleItem',
        '$Action!' => '$Action'
    );
    
    /**
     * @var DataObject
     */
    protected $record;

    /**
     * @var SS_List
     */
    protected $items;
    
    /**
     * @var array Config for this field used in both, php and javascript 
     * (will be merged into the config of the javascript file upload plugin).
     * See framework/_config/FrontendImageField.yml for configuration defaults and documentation.
     */
    protected $ufConfig = array(
        /**
         * php validation of allowedMaxFileNumber only works when a db relation is available, set to null to allow
         * unlimited if record has a has_one and allowedMaxFileNumber is null, it will be set to 1
         * @var int
         */
        'allowedMaxFileNumber' => null,
        /**
         * @var boolean|string Can the user upload new files, or just select from existing files.
         * String values are interpreted as permission codes.
         */
        'canUpload' => true,
        /**
         * @var boolean If a second file is uploaded, should it replace the existing one rather than throwing an errror?
         * This only applies for has_one relationships, and only replaces the association
         * rather than the actual file database record or filesystem entry.
         */
        'replaceExistingFile' => false,
        /**
         * @var int
         */
        'previewMaxWidth' => 80,
        /**
         * @var int
         */
        'previewMaxHeight' => 60
    );
    
    /**
     * @param string $name The internal field name, passed to forms.
     * @param string $title The field label.
     * @param SS_List $items If no items are defined, the field will try to auto-detect an existing relation on
     *                       @link $record}, with the same name as the field name.
     * @param Form $form Reference to the container form
     */
    public function __construct($name, $title = null, SS_List $items = null) {
        $this->ufConfig = array_merge($this->ufConfig, Config::inst()->get('UploadField', 'defaultConfig'));

        parent::__construct($name, $title);

        if($items) $this->setItems($items);
        
        $this->getValidator()->setAllowedExtensions(array(
            'jpg',
            'jpeg',
            'png',
            'gif'
        )); 
        
        // get the lower max size
        $this->getValidator()->setAllowedMaxFileSize(min(File::ini2bytes(ini_get('upload_max_filesize')),
            File::ini2bytes(ini_get('post_max_size'))));
    }
    
    /**
     * Force a record to be used as "Parent" for uploaded Files (eg a Page with a has_one to File)
     * @param DataObject $record
     */
    public function setRecord($record) {
        $this->record = $record;
        return $this;
    }
    /**
     * Get the record to use as "Parent" for uploaded Files (eg a Page with a has_one to File) If none is set, it will
     * use Form->getRecord() or Form->Controller()->data()
     * @return DataObject
     */
    public function getRecord() {
        if (!$this->record && $this->form) {
            if ($this->form->getRecord() && is_a($this->form->getRecord(), 'DataObject')) {
                $this->record = $this->form->getRecord();
            }
        }
        return $this->record;
    }

    /**
     * @param SS_List $items
     */
    public function setItems(SS_List $items) {
        $this->items = $items; 
        return $this;
    }
    
    /**
     * Hack to add some Variables and a dynamic template to a File
     * @param File $file
     * @return ViewableData_Customised
     */
    protected function customiseFile(File $file) {
        $file = $file->customise(array(
            'UploadFieldHasRelation' => $this->managesRelation(),
            'UploadFieldThumbnailURL' => $this->getThumbnailURLForFile($file),
            'UploadFieldDeleteLink' => $this->getItemHandler($file->ID)->DeleteLink()
        ));
        // we do this in a second customise to have the access to the previous customisations
        return $file;
    }

    /**
     * @return SS_List
     */
    public function getItems() {
        $name = $this->getName();
        if (!$this->items || !$this->items->exists()) {
            $record = $this->getRecord();
            $this->items = array();
            // Try to auto-detect relationship
            if ($record && $record->exists()) {
                if ($record->has_many($name) || $record->many_many($name)) {
                    // Ensure relationship is cast to an array, as we can't alter the items of a DataList/RelationList
                    // (see below)
                    $this->items = $record->{$name}()->toArray();
                } elseif($record->has_one($name)) {
                    $item = $record->{$name}();
                    if ($item && $item->exists())
                        $this->items = array($record->{$name}());
                }
            }
            // hack to provide $UploadFieldThumbnailURL, $hasRelation and $UploadFieldEditLink in template for each
            // file
            if (sizeof($this->items)) {
                foreach ($this->items as $i=>$file) {
                    $this->items[$i] = $this->customiseFile($file); 
                    if(!$file->canView()) unset($this->items[$i]); // Respect model permissions
                }
            }
			$this->items = new ArrayList($this->items);
        }

        return $this->items;
    }

    /**
     * @param string $key
     * @param mixed $val
     */
    public function setConfig($key, $val) {
        $this->ufConfig[$key] = $val;
        return $this;
    }

    /**
     * @param string $key
     * @return mixed
     */
    public function getConfig($key) {
        return $this->ufConfig[$key];
    }
    
    /**
     * Determine maximum number of files allowed to be attached
     * Defaults to 1 for has_one and null (unlimited) for 
     * many_many and has_many relations.
     * 
     * @return integer|null Maximum limit, or null for no limit
     */
    public function getAllowedMaxFileNumber() {
        $allowedMaxFileNumber = $this->getConfig('allowedMaxFileNumber');
        
        // if there is a has_one relation with that name on the record and 
        // allowedMaxFileNumber has not been set, it's wanted to be 1
        if(empty($allowedMaxFileNumber)) {
            $record = $this->getRecord();
            $name = $this->getName();
            if($record && $record->has_one($name)) {
                return 1; // Default for has_one
            } else {
                return null; // Default for has_many and many_many
            }
        } else {
            return $allowedMaxFileNumber;
        }
    }
    
    
    /**
     * Limit allowed file extensions. Empty by default, allowing all extensions.
     * To allow files without an extension, use an empty string.
     * See {@link File::$allowed_extensions} to get a good standard set of
     * extensions that are typically not harmful in a webserver context.
     * See {@link setAllowedMaxFileSize()} to limit file size by extension.
     * 
     * @param array $rules List of extensions
     * @return UploadField Self reference
     */
    public function setAllowedExtensions($rules) {
        $this->getValidator()->setAllowedExtensions($rules);
        return $this;
    }
    
    /**
     * Limit allowed file extensions by specifying categories of file types.
     * These may be 'image', 'audio', 'mov', 'zip', 'flash', or 'doc'
     * See {@link File::$allowed_extensions} for details of allowed extensions
     * for each of these categories
     * 
     * @param string $category Category name
     * @param string,... $categories Additional category names
     * @return UploadField Self reference
     */
    public function setAllowedFileCategories($category) {
        $extensions = array();
        $knownCategories = File::config()->app_categories;
        
        // Parse arguments
        $categories = func_get_args();
        if(func_num_args() === 1 && is_array(reset($categories))) {
            $categories = reset($categories);
        }
        
        // Merge all categories into list of extensions
        foreach(array_filter($categories) as $category) {
            if(isset($knownCategories[$category])) {
                $extensions = array_merge($extensions, $knownCategories[$category]);
            } else {
                user_error("Unknown file category: $category", E_USER_ERROR);
            }
        }
        return $this->setAllowedExtensions($extensions);
    }
    
    /**
     * Returns list of extensions allowed by this field, or an empty array
     * if there is no restriction
     * 
     * @return array
     */
    public function getAllowedExtensions() {
        return $this->getValidator()->getAllowedExtensions();
    }

    /**
     * Determine maximum number of files allowed to be attached.
     * 
     * @param integer|null $allowedMaxFileNumber Maximum limit. 0 or null will be treated as unlimited
     * @return UploadField Self reference
     */
    public function setAllowedMaxFileNumber($allowedMaxFileNumber) {
        return $this->setConfig('allowedMaxFileNumber', $allowedMaxFileNumber);
    }
    
    /**
     * @param File $file
     * @return string
     */
    protected function getThumbnailURLForFile(File $file) {
        if ($file && $file->exists() && file_exists(Director::baseFolder() . '/' . $file->getFilename())) {
            if ($file->hasMethod('getThumbnail')) {
                return $file->getThumbnail($this->getConfig('previewMaxWidth'),
                    $this->getConfig('previewMaxHeight'))->getURL();
            } elseif ($file->hasMethod('getThumbnailURL')) {
                return $file->getThumbnailURL($this->getConfig('previewMaxWidth'),
                    $this->getConfig('previewMaxHeight'));
            } elseif ($file->hasMethod('SetRatioSize')) {
                return $file->SetRatioSize($this->getConfig('previewMaxWidth'),
                    $this->getConfig('previewMaxHeight'))->getURL();
            } else {
                return $file->Icon();
            }
        }
        return false;
    }

    public function Field($properties = array()) {
        $record = $this->getRecord();
        $name = $this->getName();

        // if there is a has_one relation with that name on the record and 
        // allowedMaxFileNumber has not been set, it's wanted to be 1
        if(
            $record && $record->exists()
            && $record->has_one($name)
        ) {
            $this->setConfig('allowedMaxFileNumber', 1);
        }

        Requirements::javascript(THIRDPARTY_DIR . '/jquery/jquery.js');
        Requirements::javascript(THIRDPARTY_DIR.'/jquery-form/jquery.form.js');
        Requirements::css('general/thirdparty/bootstrap-fileupload/bootstrap-fileupload.min.css');
        Requirements::javascript('general/thirdparty/bootstrap-fileupload/bootstrap-fileupload.min.js');
        Requirements::javascript('general/javascript/FrontendImageField.min.js');

        $config = array(
            'url' => $this->Link('upload'),
            'acceptFileTypes' => '.+$',
            'maxNumberOfFiles' => $this->getConfig('allowedMaxFileNumber')
        );
        if (count($this->getValidator()->getAllowedExtensions())) {
            $allowedExtensions = $this->getValidator()->getAllowedExtensions();
            $config['acceptFileTypes'] = '(\.|\/)(' . implode('|', $allowedExtensions) . ')$';
            $config['errorMessages']['acceptFileTypes'] = _t(
                'FrontendImageField.INVALIDEXTENSIONSHORT', 
                'Extension is not allowed'
            );
        }
        if ($this->getValidator()->getAllowedMaxFileSize()) {
            $config['maxFileSize'] = $this->getValidator()->getAllowedMaxFileSize();
            $config['errorMessages']['maxFileSize'] = _t(
                'FrontendImageField.TOOLARGESHORT', 
                'Filesize exceeds {size}',
                array('size' => File::format_size($config['maxFileSize']))
            );
        }
        if ($config['maxNumberOfFiles'] > 1) {
            $config['errorMessages']['maxNumberOfFiles'] = _t(
                'FrontendImageField.MAXNUMBEROFFILESSHORT', 
                'Can only upload {count} files',
                array('count' => $config['maxNumberOfFiles'])
            );
        }
        $configOverwrite = array();
        if (is_numeric($config['maxNumberOfFiles']) && $this->getItems()->count()) {
            $configOverwrite['maxNumberOfFiles'] = $config['maxNumberOfFiles'] - $this->getItems()->count();
        }
        
        $config = array_merge($config, $this->ufConfig, $configOverwrite);
        return $this->customise(array(
            'configString' => str_replace('"', "'", Convert::raw2json($config)),
            'uploadLink' => ($record && $record->exists()) ? $this->Link('upload/'.$record->ClassName.'/'.$record->ID) : '#',
            'recordConfig' => new ArrayData($config),
            'displayInput' => (!isset($configOverwrite['maxNumberOfFiles']) || $configOverwrite['maxNumberOfFiles'])
        ))->renderWith($this->getTemplates());
    }

    /**
     * Validation method for this field, called when the entire form is validated
     * 
     * @param $validator
     * @return Boolean
     */
    public function validate($validator) {
        return true;
    }
    
    /**
     * @param SS_HTTPRequest $request
     * @return UploadField_ItemHandler
     */
    public function handleItem(SS_HTTPRequest $request) {
        if(!$request->param('ClassName')) return $this->httpError(400);
        $record = DataObject::get_by_id($request->param('ClassName'), (int)$request->param('RecordID'));
        if(!$record) return $this->httpError(400);
        $this->setRecord($record);
        return $this->getItemHandler($request->param('ID'));
    }

    /**
     * @param int $itemID
     * @return UploadField_ItemHandler
     */
    public function getItemHandler($itemID) {
        return ImageUploadField_ItemHandler::create($this, $itemID);
    }

    /**
     * Action to handle upload of a single file
     * 
     * @param SS_HTTPRequest $request
     * @return string json
     */
    public function doUpload(SS_HTTPRequest $request) {
        if($this->isDisabled() || $this->isReadonly() || !$this->canUpload()) {
            return $this->httpError(403);
        }
        
        // Protect against CSRF on destructive action
        $token = $this->getForm()->getSecurityToken();
        if(!$token->checkRequest($request)) return $this->httpError(400);

        $name = $this->getName();
        $tmpfile = $request->postVar($name);
        if(!$request->param('ClassName')) return $this->httpError(400);
        $record = DataObject::get_by_id($request->param('ClassName'), (int)$request->param('ID'));
        if(!$record) return $this->httpError(400);
        $this->setRecord($record);
        
        // Check if the file has been uploaded into the temporary storage.
        if (!$tmpfile) {
            $return = array('error' => _t('FrontendImageField.FIELDNOTSET', 'File information not found'));
        } else {
            $return = array(
                'name' => $tmpfile['name'],
                'size' => $tmpfile['size'],
                'type' => $tmpfile['type'],
                'error' => $tmpfile['error']
            );
        }

        // Check for constraints on the record to which the file will be attached.
        if (!$return['error'] && $this->relationAutoSetting && $record && $record->exists()) {
            $tooManyFiles = false;
            // Some relationships allow many files to be attached.
            if ($this->getConfig('allowedMaxFileNumber') && ($record->has_many($name) || $record->many_many($name))) {
                if(!$record->isInDB()) $record->write();
                $tooManyFiles = $record->{$name}()->count() >= $this->getConfig('allowedMaxFileNumber');
            // has_one only allows one file at any given time.
            } elseif($record->has_one($name)) {
                // If we're allowed to replace an existing file, clear out the old one
                if($record->$name && $this->getConfig('replaceExistingFile')) {
                    $record->$name = null;
                }
                $tooManyFiles = $record->{$name}() && $record->{$name}()->exists();
            }

            // Report the constraint violation.
            if ($tooManyFiles) {
                if(!$this->getConfig('allowedMaxFileNumber')) $this->setConfig('allowedMaxFileNumber', 1);
                $return['error'] = _t(
                    'FrontendImageField.MAXNUMBEROFFILES', 
                    'Max number of {count} file(s) exceeded.',
                    array('count' => $this->getConfig('allowedMaxFileNumber'))
                );
            }
        }

        // Process the uploaded file
        if (!$return['error']) {
            $fileObject = null;

            if ($this->relationAutoSetting) {
                // Search for relations that can hold the uploaded files.
                if ($relationClass = $this->getRelationAutosetClass()) {
                    // Create new object explicitly. Otherwise rely on Upload::load to choose the class.
                    $fileObject = Object::create($relationClass);
                }
            }

            // Get the uploaded file into a new file object.
            try {
                $this->upload->loadIntoFile($tmpfile, $fileObject, $this->folderName);
            } catch (Exception $e) {
                // we shouldn't get an error here, but just in case
                $return['error'] = $e->getMessage();
            }

            if (!$return['error']) {
                if ($this->upload->isError()) {
                    $return['error'] = implode(' '.PHP_EOL, $this->upload->getErrors());
                } else {
                    $file = $this->upload->getFile();

                    // Attach the file to the related record.
                    if ($this->relationAutoSetting) {
                        $this->attachFile($file);
                    }

                    // Collect all output data.
                    $return['content'] = $this->FieldHolder()->value;
                }
            }
        }
        $response = new SS_HTTPResponse(Convert::array2json($return));
        $response->addHeader('Content-Type', 'application/json');
        return $response;
    }

    /**
     * @param File
     */
    protected function attachFile($file) {
        $record = $this->getRecord();
        $name = $this->getName();
        if ($record && $record->exists()) {
            if ($record->has_many($name) || $record->many_many($name)) {
                if(!$record->isInDB()) $record->write();
                $record->{$name}()->add($file);
            } elseif($record->has_one($name)) {
                $record->{$name . 'ID'} = $file->ID;
                $record->write();
            }
        }
    }
    
    public function performReadonlyTransformation() {
        $clone = clone $this;
        $clone->addExtraClass('readonly');
        $clone->setReadonly(true);
        return $clone;
    }

    /**
     * Determines if the underlying record (if any) has a relationship
     * matching the field name. Important for permission control.
     * 
     * @return boolean
     */
    public function managesRelation() {
        $record = $this->getRecord();
        $fieldName = $this->getName();
        return (
            $record 
            && ($record->has_one($fieldName) || $record->has_many($fieldName) || $record->many_many($fieldName))
        );
    }

    /**
     * Gets the foreign class that needs to be created.
     *
     * @return string Foreign class name.
     */
    public function getRelationAutosetClass() {
        $name = $this->getName();
        $record = $this->getRecord();

        if (isset($name) && isset($record)) return $record->getRelationClass($name);
    }

    public function isDisabled() {
        return (parent::isDisabled() || !$this->isSaveable());
    }

    /**
     * Determines if the field can be saved into a database record.
     * 
     * @return boolean 
     */
    public function isSaveable() {
        $record = $this->getRecord();
        // Don't allow upload or edit of a relation when the underlying record hasn't been persisted yet
        return (!$record || !$this->managesRelation() || $record->exists());
    }

    public function canUpload() {
        $can = $this->getConfig('canUpload');
        return (is_bool($can)) ? $can : Permission::check($can);
    }
}

/**
 * RequestHandler for actions (edit, remove, delete) on a single item (File) of the UploadField
 * 
 * @author Zauberfisch
 * @package framework
 * @subpackage forms
 */
class ImageUploadField_ItemHandler extends RequestHandler {
    /**
     * @var array
     */
    private static $allowed_actions = array(
        'remove',
        'delete'
    );
    
    /**
     * @var UploadFIeld
     */
    protected $parent;

    /**
     * @var int FileID
     */
    protected $itemID;

    private static $url_handlers = array(
        '$Action!' => '$Action',
        '' => 'index',
    );

    /**
     * @param UploadFIeld $parent
     * @param int $item
     */
    public function __construct($parent, $itemID) {
        $this->parent = $parent;
        $this->itemID = $itemID;

        parent::__construct();
    }

    /**
     * @return File
     */
    public function getItem() {
        return DataObject::get_by_id('File', $this->itemID);
    }

    /**
     * @param string $action
     * @return string
     */
    public function Link($action = null) {
        return Controller::join_links($this->parent->Link(), '/item/', $this->parent->getRecord()->ClassName, $this->parent->getRecord()->ID, $this->itemID, $action);
    }

    /**
     * @return string
     */
    public function DeleteLink() {
        $token = $this->parent->getForm()->getSecurityToken();
        return $token->addToUrl($this->Link('delete'));
    }

    /**
     * Action to handle removing a single file from the db relation
     * 
     * @param SS_HTTPRequest $request
     * @return SS_HTTPResponse
     */
    public function remove(SS_HTTPRequest $request) {
        // Check form field state
        if($this->parent->isDisabled() || $this->parent->isReadonly()) return $this->httpError(403);

        // Protect against CSRF on destructive action
        $token = $this->parent->getForm()->getSecurityToken();
        if(!$token->checkRequest($request)) return $this->httpError(400);

        $response = new SS_HTTPResponse();
        $response->setStatusCode(500);
        $fieldName = $this->parent->getName();
        $record = $this->parent->getRecord();
        $id = $this->getItem()->ID;
        if ($id && $record && $record->exists()) {
            if (($record->has_many($fieldName) || $record->many_many($fieldName))
                    && $file = $record->{$fieldName}()->byID($id)) {

                $record->{$fieldName}()->remove($file);
                $response->setStatusCode(200);
            } elseif($record->has_one($fieldName) && $record->{$fieldName . 'ID'} == $id) {
                $record->{$fieldName . 'ID'} = 0;
                $record->write();
                $response->setStatusCode(200);
            }
        }
        if ($response->getStatusCode() != 200){
            $response->setStatusDescription(_t('FrontendImageField.REMOVEERROR', 'Error removing file'));
        }
        else{
            $response->setBody($this->parent->setItems(null)->FieldHolder());
            $response->addHeader('Content-Type', 'text/html');
        }
        return $response;
    }

    /**
     * Action to handle deleting of a single file
     * 
     * @param SS_HTTPRequest $request
     * @return SS_HTTPResponse
     */
    public function delete(SS_HTTPRequest $request) {
        // Check form field state
        if($this->parent->isDisabled() || $this->parent->isReadonly()) return $this->httpError(403);

        // Protect against CSRF on destructive action
        $token = $this->parent->getForm()->getSecurityToken();
        if(!$token->checkRequest($request)) return $this->httpError(400);

        // Check item permissions
        $item = $this->getItem();
        if(!$item) return $this->httpError(404);
        if(!$item->canDelete()) return $this->httpError(403);

        // Only allow actions on files in the managed relation (if one exists)
        $items = $this->parent->getItems();
        if($this->parent->managesRelation() && !$items->byID($item->ID)) return $this->httpError(403);

        // First remove the file from the current relationship
        $response = $this->remove($request);

        // Then delete the file from the filesystem
        $item->delete();
        
        return $response;
    }
}
?>
